Skip to content

Phase 1: token system, build pipeline, release automation, docs, and eight components - #1

Merged
karngyan merged 33 commits into
mainfrom
worktree-dowel-phase-1
Aug 9, 2026
Merged

Phase 1: token system, build pipeline, release automation, docs, and eight components#1
karngyan merged 33 commits into
mainfrom
worktree-dowel-phase-1

Conversation

@karngyan

@karngyan karngyan commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Phase 1 of dowel: the token system, build pipeline, release automation, docs site, and eight components — end to end.

What ships

dowel@0.1.0 — a real npm package. pnpm add dowel, import a component, import one stylesheet. No Tailwind, no PostCSS config, no preset, no copy-in generator.

Eight components, all Base UI-backed where behaviour exists:
Button · IconButton · Badge · Kbd · Input + Field · Dialog · Menu · Tooltip

Design foundation measured from a live audit of linear.app, not guessed. Three rules carry most of the look:

  1. Every neutral is lch(L C 272) at chroma 0.4–1.93 — the greys aren't grey
  2. Base text weight 450 (not 400), UI labels 500, 13px workhorse size, -0.02em tracking
  3. Every control is 28px, 0.15s, and only border, background-color, color, opacity ever animate

Plus 0.5px hairlines and exactly two elevation tiers (3-layer popover, 5-layer modal).

Docs at apps/docs — TanStack Start, prerendered to static HTML, dogfooding dowel, with a working light/dark toggle. Deploys to dowel.sh as an assets-only Cloudflare Worker.

The opinion, enforced

There is no per-component override API. className and style are Omited from every public prop type, and every component spreads {...props} before its own attributes so a consumer spread can't strip them either. Retheming is three CSS variables: --dowel-hue, --dowel-accent, --dowel-accent-fg.

The final review typechecked a real consumer file against the built dist/index.d.ts and confirmed all 37 override attempts across every part — including the compound Roots — are type errors.

Homage

dowel reimplements Linear's visual language — colour relationships, spacing, density, radii, motion curves — which is not protectable. It ships none of their assets: no logo, no icons, no licensed typefaces. The README credits them and states plainly that dowel is unaffiliated and unendorsed.

How it was built

14 tasks, each implemented by a fresh subagent and independently reviewed, with adversarial verification of every claim. The review loop caught defects that would otherwise have shipped, including:

  • Spread-order hole<Button {...{className:"evil"}} /> typechecked clean and stripped dowel-btn entirely, leaving components unstyled. Caught at Button, so fixed once instead of eight times.
  • Dialog.Portal inline-style channel — Base UI's Portal renders a real <div>; a consumer transform there creates a containing block and silently breaks the dialog's positioning.
  • Field.Error was unreachable — prop types derived from "div"/"p" instead of Base UI dropped invalid/validate/match. We were about to export a component no type-checking consumer could render.
  • A test passing vacuously — Menu's "closes on Escape" passed even with the Escape keypress deleted, because Base UI opens on a rAF and a synchronous getByRole masked it.
  • A false theming promise--dowel-accent-hover was hardcoded, so overriding the accent gave a Button that snapped back to default purple on hover. Now color-mix-derived.

Several of those were defects in the plan, not the implementations.

Verification

  • 81 tests across 10 files — behaviour, keyboard nav, and axe-core per component
  • Every component tested in both themes
  • A build-contract test fails on any unresolved var(--dowel-*) in the emitted CSS
  • A token-parity test fails if light/dark drift, at the value level
  • format:check, typecheck, build, test all green in CI order

Post-merge (not doable before)

  1. Confirm the deploy run and curl https://dowel.sh → 200. Custom domains take a few minutes to attach; a first-try 522/404 isn't a bug.
  2. Merge the auto-opened Version Packages PR to publish dowel@0.1.0, then verify the npm dist-tag.
  3. www.dowel.sh needs a Cloudflare dashboard Redirect Rule — an assets-only Worker can't do it.
  4. Only after a green release run, delete the shadowing repo-level secrets, then re-run one release to confirm the org secret is picked up:
    gh secret delete NPM_TOKEN --repo karnstack/kino
    gh secret delete NPM_TOKEN --repo karnstack/reins
    gh secret delete CLOUDFLARE_API_TOKEN --repo karnstack/flue
    

Known follow-ups

  • dowel.sh returns a bare 404 on unknown URLs — add a 404.html and flip not_found_handling
  • Compound components don't export their *Props types; typed wrappers need ComponentProps<typeof Dialog.Popup>
  • Tooltip has no aria-describedby — Base UI's documented stance is that tooltips are visual labels only. IconButton's required label guarantees the accessible name; wiring it ourselves is a recorded phase-2 decision.

🤖 Generated with Claude Code

karngyan and others added 30 commits August 9, 2026 03:34
tsup's last npm release was 2025-11-12 (nine months ago) with 409 open
issues; tsdown ships actively under the rolldown org and targets libraries
specifically. Neither is formally deprecated on npm, but the maintenance
signal is one-sided.

tsdown externalizes dependencies and peerDependencies by default, so the
explicit external array is gone — react, react-dom and @base-ui/react are
external without configuration. tsdown's own external option is deprecated
in favour of deps.neverBundle; neither is needed here.

Also documents that clean: true makes build order load-bearing: tsdown
must run before build-css.mjs or dist/dowel.css is deleted after it is
written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-flight scan: every component called cx("dowel-x") with exactly one
string literal, so cx was an identity function at all 18 call sites. The
design expresses variants as data-* attributes, so no component ever builds
a conditional class name and the helper has no use case.

Components now write className="dowel-x" directly. src/lib/cx.ts is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The name-set parity test pools both dark copies into one Set, so a value
edited in one copy but not the other -- or a token dropped from a single
copy -- shipped with a green suite. Split dark.css on the media-query
boundary and assert the two copies' name->value maps are deeply equal.
Also point light.css's header at scale.css, where --dowel-hue now lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… test

tsdown emits ESM + d.ts from an empty barrel; build-css.mjs bundles the
token CSS through Lightning CSS into one minified dist/dowel.css. The
css-contract suite asserts the bundle exists, resolves every --dowel-
var() it references, inlines all @imports, and keeps the cascade layers.
tsdown's clean:true wipes dist/, so the build script runs tsdown first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tyling

Review fixes for Task 4, all three replicate into Tasks 5-10:
- Spread props before className/style/data-* so consumers cannot smuggle
  overrides through a wider object; guarded by a new test.
- Forward Base UI's nativeButton so render={<a/>} produces valid DOM
  (no type attr, no dev warning); two tests updated/added.
- Style aria-disabled like :disabled for non-form-control renders.
- renderBoth docstring now states what jsdom can actually check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review residuals, each copied by Tasks 5-10:
- Variant hover rules now exclude [aria-disabled=true] so disabled
  anchors stay inert. Verified in dist/dowel.css; no jsdom test, since
  @layer rules and :hover styling are invisible to it and the test
  could not fail.
- Spread-order comment narrowed: render's element keeps its own
  attributes by design.
- renderBoth docstring points at tokens.test.ts, the actual parity guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two plain-span display components with no Base UI dependency. Both follow
the Task 4 hardening pattern: consumer props spread first, then className,
style={undefined}, and data-* so a spread can never strip the dowel class
or inject inline style. Kbd omits children from its props — the keys array
is the only content source, and JSX children beat any smuggled children.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Field parts previously typed their props from intrinsic div/p elements,
which dropped Base UI's invalid/validate/validationMode/match and made the
exported Field.Error unreachable through the typed API. Props now derive
from each Base UI component's call signature (className/style still
omitted). Field.Label additionally omits htmlFor — a hand-written htmlFor
wins over the generated association and silently breaks label
click-to-focus. Field.Error's ref is HTMLDivElement, matching what Base UI
renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 7 hit TS2344: ComponentProps<T> rejects the
(...args: never) => unknown constraint. Tasks 8, 9 and 10 all carried the
same non-compiling helper, so fix it in the plan before their briefs are
generated rather than three more times in review.

Replaced with the form Task 7 proved compiles (infer the props parameter
from the call signature) and dropped the now-unused ComponentProps import
from all three component files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… return

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Provider and Root stay raw passthroughs — verified against Base UI's .d.ts:
neither renders an HTML element and neither's props carry className/style.
Trigger, Portal, Positioner and Popup all extend BaseUIComponentProps
(button/div/div/div), so all four are wrapped with the spread-first
neutralisation pattern.

Base UI 1.7 leaves the popup role-less (and wires no aria-describedby), so
dowel's Popup sets role="tooltip" as an overridable default — accurate for
a plain text label, and what makes role-based queries work at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base UI's role-less popup is documented design stance, not a limitation:
their shipped docs state tooltips are visual labels only and the trigger
must carry an aria-label closely matching the tooltip content. Record
that contract above Popup's role="tooltip" default and pin it with a
test — the role+name query runs the full accessible-name computation,
asserted with the popup closed because assistive tech never sees it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- .changeset/config.json ships ignore: []; Task 13 adds @dowel/docs in
  the same commit that creates the package (changesets hard-errors on
  ignore entries that match no package, and with the docs app present
  but unignored it writes a phantom patch bump — the entry must move,
  not vanish).
- --dowel-accent-hover is now color-mix-derived from --dowel-accent
  (92% toward black in light, 85% toward white in dark) so a retheme
  keeps its hover. Percentages chosen by resolving candidates with
  Lightning CSS against the old hardcoded hovers.
- Both READMEs now name the real theming surface: --dowel-hue,
  --dowel-accent, --dowel-accent-fg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs dogfood dowel: every component on the page comes from the
workspace package through its published exports, so a broken export map
breaks the docs build rather than a consumer's.

Prerendered to static HTML. Docs discovery is search-driven and nothing
here needs a request, so the deploy target is an assets-only Worker with
no runtime. The client environment writes straight to dist/ instead of
the default dist/client, which makes the deploy "upload dist/" with no
server bundle sitting next to the HTML; the SSR build exists only to
render those pages, so it goes to .tanstack/ and is gitignored.

The brief's app.config.ts shape does not exist in Start 1.168 — there is
no @tanstack/react-start/config export at all. Configuration is a Vite
plugin now (@tanstack/react-start/plugin/vite), and `server.preset:
"static"` is replaced by `pages` + `prerender`. Seeding the crawler with
"/" and letting crawlLinks follow the nav means a new route linked from
the shell prerenders without touching the config.

Two constraints the docs have to state, because both are invisible from
the type signatures: dowel ships no typeface — it names "Inter Variable"
first in --dowel-font and expects the app to supply it, which the docs do
via @fontsource-variable/inter — and a Tooltip is a visual label only.
Base UI deliberately writes no aria-describedby, so the trigger has to
carry its own accessible name and hover content that must reach assistive
tech belongs in a Popover.

Changesets ignores @dowel/docs. The package is private, and with an empty
ignore list changesets writes a phantom 0.0.1 bump and a CHANGELOG for a
package that is never published.

The .prettierignore fix is not cosmetic: `docs/` is a gitignore-style
pattern that matches a directory of that name at any depth, so adding
apps/docs silently excluded the entire new app from format:check.
Anchoring it to /docs/ puts the app back under the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assets-only Worker config (html_handling auto-trailing-slash so directory
sub-pages resolve; not_found_handling none — no 404.html is emitted) plus a
main-branch deploy workflow that skips cleanly when the org
CLOUDFLARE_API_TOKEN is absent and pins wrangler-action to wrangler v4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document theming, the one thing the READMEs never explained, and clear the
four minors left open across the branch.

- Theming section in packages/dowel/README.md, verified against source: the
  three dark paths (.dowel-dark, [data-dowel-theme="dark"], and the
  prefers-color-scheme rule on
  :root:not(.dowel-light):not([data-dowel-theme="light"])), what .dowel-root
  actually supplies, and a retheming snippet that states the :root
  requirement. Custom properties resolve on the declaring element, so a
  nested --dowel-accent override leaves the color-mix-derived hover behind.
  Root README aligned but kept short; both now state dowel is ESM-only.
- user-select: none on .dowel-icon-btn, matching button.css and menu.css.
- pretest builds before the CSS contract suite, so a fresh clone no longer
  fails four tests with "has not been built". Not circular and ~2.4s; the
  test keeps a named message as a backstop for bare vitest runs.
- src/index.ts drops the "appended here by each component task" scaffolding.
- Docs site gets a light/dark toggle in the nav, dogfooding IconButton and
  driving data-dowel-theme from state. No DOM access, so prerendering is
  unaffected and all three pages still emit. Covered by a new
  onClick-forwarding test on IconButton.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old lch(49% 62 285) rendered as a saturated azure that read as generic
Bootstrap blue. Teal is karnstack's house colour, and hue 195 at a restrained
chroma is ours rather than a borrowed brand.

Light is lch(52% 32 195) (#198b89). Dark lifts it to lch(68% 36 195) so it
carries on near-black surfaces — 7.71:1 against bg-1. Chroma runs out sooner
than it looks at this hue: lch(64% 40 195) and anything more saturated at that
lightness clips the sRGB green channel, so the dark value sits just inside the
edge.

--dowel-accent-fg now differs by theme, which is the substantive change.
Teal is luminous for its lightness, so the dark accent leaves white text at
2.43:1 — worse than the azure's already-failing 3.31:1. Dark therefore draws a
near-black ink from the accent's own hue: 6.42:1 at rest, 7.44:1 on hover.

Light keeps white at 4.12:1. That is under AA, and deliberately so: against an
L=52 background no ink clears 4.5:1 in both states — black trades the numbers
round at 5.10:1 rest and 4.20:1 hover. White wins on the hover state and reads
visibly cleaner at 13px. Reaching AA at rest needs the accent itself to drop to
about lch(49% 32 195), which is a call for whoever owns the brand value.

The color-mix() hover derivations are untouched, so overriding --dowel-accent
still carries into hover and focus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs rendered as unstyled prose on a dark background. This gives them
shadcn's three-column shape: sticky header, sticky sidebar nav, a content
column at 72ch, and an "On this page" rail with scroll-spy — collapsing to one
column with a hamburger disclosure below 64rem.

Plain CSS on dowel's own tokens throughout. No Tailwind, in the library or the
docs. Local --docs-* tokens exist only where dowel has none: page-scale
spacing (dowel's space scale tops out at 18px, sized for the inside of a
control), layout widths, and syntax colours.

Demos weld the preview surface and its source into one bordered container so
the two cannot drift, and every preview is real dowel imported from the
package. Syntax highlighting is a ~60-line TSX lexer rather than a dependency —
Shiki and Prism are each larger than everything else in this app combined, and
the docs only ever highlight snippets we wrote. It is a pure string -> tokens
function, so the prerender can run it.

Eleven pages prerender: landing, an index, and eight component pages.

The theme toggle now defaults to the OS rather than hardcoding dark. That is
also the only hydration-safe initial value — the server cannot read the OS
setting, so asserting nothing is the one choice that always matches the
client. Both icons ship and CSS picks the one matching the resolved theme,
which a JS-chosen icon could not do in prerendered markup.

Two library fixes fell out of dogfooding: Button and IconButton rendered
through the documented render={<a />} escape hatch were arriving underlined
from the UA sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing D — bare, either case — flips the docs theme that the header
toggle already flips, and the toggle's tooltip advertises it with Kbd.

The listener is attached in an effect so it only ever exists in the
browser; the site is statically prerendered and there is no document
while the HTML is generated.

Three guards keep a bare-letter shortcut from stealing keystrokes. It
yields to anything the user is typing into — input, textarea, select,
or a computed contenteditable, so nodes nested inside an editing host
count too. It yields to a held Meta/Ctrl/Alt so it can never shadow a
browser shortcut, and to defaultPrevented so a handler closer to the
keystroke wins. And it yields while a Dialog or Menu is open, matched
on Base UI's [data-open] rather than the popup's presence in the DOM:
the attribute is dropped for the closing animation while the element
is still mounted, so presence would suppress the key after the overlay
had visually gone.

toggleTheme now reads the media query before setTheme and resolves the
current theme in a functional updater, which keeps the updater pure and
leaves the toggle depending on nothing — so the listener is attached
once instead of rebound on every flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
karngyan and others added 3 commits August 9, 2026 19:51
The pending minor changeset would have published dowel@0.1.0 the moment
this branch merged. More refactors are queued before the API is worth
freezing, so it goes. Changesets then fails CI on "packages changed, no
changeset found" — the whole branch is new relative to main — so an empty
changeset takes its place, which is the documented way to say this change
needs no release. `changeset status` is green again and dowel stays at
0.0.0.

The docs had no favicon. The mark is the header wordmark's pin (a dowel:
the small turned pin that joins two pieces) set into an accent tile. The
bare pin is right at 18px against the page background, but a favicon is
16px on chrome the site does not control, and there a lone diagonal
capsule reads as a faint slash on light chrome and vanishes into dark. The
tile brings its own contrast, so the mark holds either way, and the pin
knocks out of it at a width that survives 16px.

icon.svg is primary and follows the OS scheme via prefers-color-scheme,
tracking dowel-accent under both. favicon.ico (16 and 32) and
apple-touch-icon.png cover what will not take an SVG. They are declared on
the root route, so all eleven prerendered pages carry them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The notes existed in the worktree but were never tracked, so nothing in a
fresh checkout carried them. The dash rule in particular has to live in the
repo to be worth anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The badge was hand-typed as v0.1.0 in two places while the package sits at
0.0.0 with nothing published, so the site advertised a release that does not
exist. Hardcoding it also guarantees the same drift on the next bump.

vite.config.ts now imports packages/dowel/package.json by relative path and
bakes the version in with `define`. The relative path is required: dowel's
exports map does not expose ./package.json, so the bare specifier cannot
resolve. A `define` is a literal substitution, which is what keeps it working
under static prerendering, where there is no runtime to read a file.

src/lib/version.ts turns that into the copy both call sites render. While the
manifest holds the unpublished 0.0.0 placeholder the badge reads "unreleased",
because a version number nobody can install is worse than no number. It is a
condition on the value, not a second string, so the first real publish flips
it to v<version> with no docs change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@karngyan
karngyan merged commit d7ef259 into main Aug 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant